iT邦幫忙

2024 iThome 鐵人賽

DAY 25
1
Modern Web

Vue 和 TypeScript 的最佳實踐:成為前端工程師的進階利器系列 第 25

Day 25: 使用 Vitest 測試異步行為與 API 請求邏輯

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20240926/20117461s6OTUY4RAX.jpg

簡介

在現代 Vue.js 應用程序開發中,處理異步操作和 API 請求是常見且關鍵的任務。本文將深入探討如何使用 Vitest 來全面測試這些異步行為和 API 請求邏輯。我們將整合 TypeScript、Vue Router、Pinia、Zod、Vee-Validate、@vueuse/core 等技術,並特別關注 createFetch 的使用。通過實際例子,我們將展示如何處理各種 API 請求場景,包括文件上傳、下載、表單提交、SSE、WebSocket 等。

步驟 1: 延續先前深入測試

這篇文章我們要把 day13 最後的自定義 fetch api composable,作為測試目標
以及達成可測試目標作為目的做修改

在這個 api 我們簡單看一下架構以及核心概念。我們會用 curry function 把每個個別流程串聯再一起,讓我們可以針對個別的 method 進行單元測試

以下 curry function 簡單展示

const makeCurryFn = <T>(
  input: T,
  fnList: ((input: T) => T)[]
): T => fnList.reduce((acc, fn) => fn(acc), input);

使用方式:

(檔案: src/composables/useApiFrame.ts)

import { BeforeFetchContext } from "@vueuse/core";

export interface InputOptions {
  input1: string;
  input2: string;
}

const makeCurryFn = <T>(
  input: T,
  fnList: ((input: T) => T)[]
): T => fnList.reduce((acc, fn) => fn(acc), input);

const pipeLine1 = (input: string): (ctx: BeforeFetchContext) => BeforeFetchContext => {
  return (ctx) => {
    console.log(input);
    // do something 1...
    return ctx;
  }
};

const pipeLine2 = (input: string): (ctx: BeforeFetchContext) => BeforeFetchContext => {
  return (ctx) => {
    console.log(input);
    // do something 2...
    return ctx;
  }
};

const allMethodCombine = (options: InputOptions): (ctx: BeforeFetchContext) => BeforeFetchContext => {
  const { input1, input2 } = options;
  return (ctx) => makeCurryFn<BeforeFetchContext>(ctx, [pipeLine1(input1),pipeLine2(input2)]);
};

以上範例展示,也因為這樣我們對於 useApiFetch 的結構有初步認識了

所以可以把複雜的架構簡化

import { AfterFetchContext, BeforeFetchContext, createFetch, OnFetchErrorContext } from '@vueuse/core';

export const useApiFrame = (
  beforeFetch: (ctx: BeforeFetchContext) => BeforeFetchContext, 
  afterFetch: (ctx: AfterFetchContext) => AfterFetchContext, 
  onFetchError: (ctx: OnFetchErrorContext) => OnFetchErrorContext, 
) => {
  const useApi = () => createFetch({
    baseUrl: `${import.meta.env.VITE_APP_API_URL ?? ''}`,
    options: {
      timeout: 30000,
      beforeFetch,
      afterFetch,
      onFetchError,
    },
    fetchOptions: {
      mode: 'cors',
    }
  })

  return {
    useApi,
  };
};

export type UseApiFrame = typeof useApiFrame;

並且把型別拆分

(檔案:src/schema/api.ts)

import * as zod from 'zod'
import { MaybeRef  } from 'vue';

export type RequestInput = string | number | boolean | File;
export type RequestInputs = RequestInput | RequestInput[];
export type RequestDataStructureInputs = RequestInputs | Record<string, RequestInputs> | Record<string, RequestInputs>[];
export type RequestJsonInputs = Record<string, RequestDataStructureInputs> | Record<string, RequestDataStructureInputs>[];

export interface UseApiFetchOptions {
  isBearerTokenRequired?: boolean
  query?: MaybeRef<Record<string, RequestInputs>>
  json?: MaybeRef<RequestJsonInputs>
  formData?: MaybeRef<Record<string, RequestInputs>>
  responseSchema?: zod.ZodTypeAny
  errorResponseSchema?: zod.ZodTypeAny
}

這樣我們可以根據個別的 method 進行測試

測試 functionmethod 我們可以根據預期的方法進行測試 input 爲什麼 output 預計爲什麼,去預判每個方法符合預期
如何執行可以參考過去的測試範例,這裡緊做簡單的示範

這裡我們把 beforeFetch 的功能分割開來

(檔案: src/composables/useApiBeforeFetch.ts)

import { MaybeRef, toValue } from 'vue';
import { useLocalStorage } from '@vueuse/core';
import { AfterFetchContext, BeforeFetchContext } from "@vueuse/core";
import { RequestJsonInputs } from '../schema/api';

export const useApiBeforeFetch = () => {

  const getAuthorizationBeforeFetch = (isTokenRequired: boolean = false): ((ctx: BeforeFetchContext) => BeforeFetchContext) => {
    const token = useLocalStorage('token', '');
    if (!isTokenRequired)
      return noActionContext<BeforeFetchContext>;

    return (ctx: BeforeFetchContext) => {
      if (!token) {
        ctx.cancel();
        return ctx;
      }

      ctx.options.headers = {
        ...ctx.options.headers,
        Authorization: `Bearer ${token}`
      };
      return ctx;
    };
  };

  const getQueryBeforeFetch = <T extends string | number | boolean | File>(
    query?: MaybeRef<Record<string, T | T[]>>
  ): ((ctx: BeforeFetchContext) => BeforeFetchContext) => {
    if (!query)
      return noActionContext<BeforeFetchContext>;
    const currentQuery = toValue(query);
    if (!currentQuery)
      return noActionContext<BeforeFetchContext>;
    if (Object.keys(currentQuery).length === 0)
      return noActionContext<BeforeFetchContext>;
    return (ctx: BeforeFetchContext): BeforeFetchContext => {
      ctx.url += `?${generateQueryString(currentQuery)}`;
      return ctx;
    };
  }

  const getJsonFormatBeforeFetch = (
    jsonInput?: MaybeRef<RequestJsonInputs>
  ): ((ctx: BeforeFetchContext) => BeforeFetchContext) => {
    if (!jsonInput)
      return noActionContext<BeforeFetchContext>;
    const currentRawData = toValue(jsonInput);
    if (!currentRawData)
      return noActionContext<BeforeFetchContext>;
    return (ctx: BeforeFetchContext): BeforeFetchContext => {
      ctx.options.headers = {
        ...ctx.options.headers,
        'Content-Type': 'application/json'
      };
      ctx.options.body = JSON.stringify(removeNullishInRecursiveObject(currentRawData));
      return ctx;
    };
  };

 const noActionContext = <T extends BeforeFetchContext | AfterFetchContext>(ctx: T): T => ctx;

  const generateQueryString = <T extends string | number | boolean | File>(queryData: Record<string, T | T[]>): string => {
    const query = new URLSearchParams();

    for (const [key, value] of Object.entries(queryData)) {
      if (Array.isArray(value)) {
        value.forEach(el => query.append(key, el.toString()));
        continue;
      }

      if (checkIsNotEmpty(value)) {
        query.append(key, value.toString());
      }
    }

    const queryString = query.toString();
    return queryString.length > 0 ? `${queryString}` : '';
  };

  const checkIsNotEmpty = (val: unknown) => {
    if (typeof val === 'number' || typeof val === 'boolean')
      return true;
    return val !== '' && typeof val !== 'undefined';
  };

  const removeNullishInRecursiveObject = (obj: RequestJsonInputs): RequestJsonInputs => {
    if (Array.isArray(obj)) {
      return obj.map(el => removeNullishInRecursiveObject(el)) as RequestJsonInputs;
    }
    // if obj is boolean or number
    if (isAllowBooleanNumberString(obj))
      return obj;
    // if obj is object but not array
    const entries = Object.entries(obj)
      .filter(([, v]) => {
        if (!isAllowBooleanNumberAndObject)
          return isNotEmpty(v);
        return true;
      })
      .map(([k, v]) => {
        if (Array.isArray(v)) {
          return [
            k,
            v.filter(el => isNotEmptyExcludeEmptyString(el)).map(el => removeNullishInRecursiveObject(el as RequestJsonInputs))
          ];
        }
        if (isFile(v))
          return [k, v];
        if (typeof v === 'object') {
          return [k, removeNullishInRecursiveObject(v)];
        }
        return [k, v];
      });

    return Object.fromEntries(entries);
  };

  const isNotEmpty = (v: unknown): boolean => {
    return v !== '' && isNotEmptyExcludeEmptyString(v);
  };

  const isNotEmptyExcludeEmptyString = (v: unknown): boolean => {
    return v !== undefined && v !== null;
  };

  const isAllowBooleanNumber = (v: unknown): boolean => {
    if (typeof v === 'boolean')
      return true;
    return typeof v === 'number';
  };

  const isAllowBooleanNumberString = (v: unknown): boolean => {
    return typeof v === 'string' || isAllowBooleanNumber(v);
  };

  const isAllowBooleanNumberAndObject = (v: unknown): boolean => {
    if (isAllowBooleanNumber(v))
      return true;
    if (typeof v === 'object')
      return true;
    return false;
  };

  const isFile = (input: unknown): input is File => {
    return input instanceof File;
  };

  const convertFormDataResult = <T extends string | number | boolean | File>(input: T): File | string => {
    if (isFile(input))
      return input;
    return input.toString();
  };

  const getFormDataFormatBeforeFetch = <T extends string | number | boolean | File>(
    formDataInput?: MaybeRef<Record<string, T | T[]>>
  ): ((ctx: BeforeFetchContext) => BeforeFetchContext) => {
    if (!formDataInput)
      return noActionContext<BeforeFetchContext>;
    const currentRawData = toValue(formDataInput);
    if (!currentRawData)
      return noActionContext<BeforeFetchContext>;
    return (ctx: BeforeFetchContext): BeforeFetchContext => {
      ctx.options.body = convertObjectToFormData(currentRawData);
      return ctx;
    };
  };


  const convertObjectToFormData = <T extends string | number | boolean | File>(obj: Record<string, T | T[]>): FormData => {
    const formData = new FormData();
    for (const [key, value] of Object.entries(obj)) {
      if (Array.isArray(value)) {
        value.forEach(el => formData.append(key, convertFormDataResult(el)));
        continue;
      }
      if (value === null || typeof value === 'undefined') {
        continue;
      }
      formData.append(key, convertFormDataResult(value));
    }
    return formData;
  };

  return {
    getAuthorizationBeforeFetch,
    getQueryBeforeFetch,
    getJsonFormatBeforeFetch,
    getFormDataFormatBeforeFetch,
  };
};

export type UseApiBeforeFetch = typeof useApiBeforeFetch;

afterFetchonFetchError 也是,但因為篇幅原因所以這裡不示範

所以可以整合成

import { AfterFetchContext, BeforeFetchContext, OnFetchErrorContext } from '@vueuse/core';
import { useApiFrame } from './useApiFrame';
import { useApiBeforeFetch } from './useApiBeforeFetch';
import { useApiAfterFetch } from './useApiAfterFetch';
import { useApiErrorFetch } from './useApiErrorFetch';
import { UseApiFetchOptions } from '../schema/api'

export const useApiFetch = () => {
  // state::

  const {
    getAuthorizationBeforeFetch,
    getQueryBeforeFetch,
    getJsonFormatBeforeFetch,
    getFormDataFormatBeforeFetch,
  } = useApiBeforeFetch();

  const {
    responseSchemaAfterFetch,
    errorSchemaAfterFetch
  } = useApiAfterFetch();


  const getBeforeFetch = (option: UseApiFetchOptions) => (ctx: BeforeFetchContext) =>  BeforeFetchContext => {
    const { isBearerTokenRequired, query, json, formData } = option;
    return (ctx: BeforeFetchContext) => {
      return makeCurryFn<BeforeFetchContext>(ctx, [
        getAuthorizationBeforeFetch(isBearerTokenRequired),
        getQueryBeforeFetch(query),
        getJsonFormatBeforeFetch(json),
        getFormDataFormatBeforeFetch(formData),
      ])
    }
  };

  const getAfterFetch = (options: UseApiFetchOptions): ((ctx: AfterFetchContext) => AfterFetchContext) => {
    const { responseSchema, errorResponseSchema } = options;
    return (ctx: AfterFetchContext) =>
      makeCurryFn<AfterFetchContext>(ctx, [
        responseSchemaAfterFetch(responseSchema),
        errorSchemaAfterFetch(errorResponseSchema)
      ]);
  };

  const getErrorFetch = (options: UseApiFetchOptions): ((ctx: OnFetchErrorContext) => OnFetchErrorContext) => {
    return (ctx: OnFetchErrorContext) =>
      makeCurryFn<OnFetchErrorContext>(ctx, [
      ]);
  };

  
  const { useApi } = (options: UseApiFetchOptions) => useApiFrame(
    getBeforeFetch(options),
    getAfterFetch(options),
    getErrorFetch(options)
  )

  const makeCurryFn = <T>(
    input: T,
    fnList: ((input: T) => T)[]
  ): T => fnList.reduce((acc, fn) => fn(acc), input);

  return {
    useApi
  };
};

export type UseApiFetch = typeof useApiFetch;

如果要 mock BeforeEach

const getBeforeFetch = (option: UseApiFetchOptions) => (ctx: BeforeFetchContext) =>  BeforeFetchContext => {
    const { isBearerTokenRequired, query, json, formData } = option;
    return (ctx: BeforeFetchContext) => {
      return makeCurryFn<BeforeFetchContext>(ctx, [
        getAuthorizationBeforeFetch(isBearerTokenRequired),
        getQueryBeforeFetch(query),
        getJsonFormatBeforeFetch(json),
        getFormDataFormatBeforeFetch(formData),
      ])
    }
  };

轉化成

const getBeforeFetch = (option: UseApiFetchOptions) => (ctx: BeforeFetchContext) =>  BeforeFetchContext => {
    const { isBearerTokenRequired, query, json, formData } = option;
    return (ctx: BeforeFetchContext) => {
      return makeCurryFn<BeforeFetchContext>(ctx, [
        getMockAuthorizationBeforeFetch(isBearerTokenRequired),
        getMockQueryBeforeFetch(query),
        getMockJsonFormatBeforeFetch(json),
        getMockkFormDataFormatBeforeFetch(formData),
      ])
    }
  };

並針對 input , output 去預期結果即可

步驟 2: 實現 SSE 和 WebSocket

import { useEventSource, useWebSocket } from '@vueuse/core'

export const useSSE = (url: string) => {
  const { data, error } = useEventSource(url)
  return { data, error }
}

export const useWS = (url: string) => {
  const { data, send, open, close } = useWebSocket(url)
  return { data, send, open, close }
}

對前端來說可以用 url 去修改成測試 server 進行測試方法是否如預期

步驟 3: 實現動態路由和查詢字符串

export const getDynamicResource = (resourceType: string, id: number) =>
  apiRequest<unknown>(`/${resourceType}/${id}`)()

export const searchResources = (resourceType: string, query: Record<string, string>) => {
  const queryString = new URLSearchParams(query).toString()
  return apiRequest<unknown[]>(`/${resourceType}?${queryString}`)()
}

步驟 4: 編寫測試 (以下為簡短的示範版本,抓取個人過去的程式碼片段做演示)

現在,讓我們為這些功能編寫全面的測試:

// api.test.ts
import { describe, it, expect, vi } from 'vitest'
import { getUser, createUser, updateUser, uploadFile, downloadFile, useSSE, useWS, getDynamicResource, searchResources } from './api'
import { UserSchema } from './types'

vi.mock('@vueuse/core', () => ({
  createFetch: () => () => ({
    json: () => Promise.resolve({ data: { id: 1, name: 'John Doe', email: 'john@example.com' } })
  }),
  useEventSource: vi.fn(),
  useWebSocket: vi.fn()
}))

describe('API functions', () => {
  it('should fetch a user', async () => {
    const user = await getUser(1)(UserSchema)
    expect(user).toEqual({ id: 1, name: 'John Doe', email: 'john@example.com' })
  })

  it('should create a user', async () => {
    const newUser = { name: 'Jane Doe', email: 'jane@example.com' }
    const user = await createUser(newUser)(UserSchema)
    expect(user).toEqual({ id: 1, name: 'John Doe', email: 'john@example.com' })
  })

  it('should update a user', async () => {
    const updatedUser = { name: 'John Updated' }
    const user = await updateUser(1, updatedUser)(UserSchema)
    expect(user).toEqual({ id: 1, name: 'John Doe', email: 'john@example.com' })
  })

  it('should upload a file', async () => {
    const mockFile = new File([''], 'test.txt', { type: 'text/plain' })
    const result = await uploadFile(mockFile)
    expect(result).toEqual({ fileUrl: 'https://example.com/file.txt' })
  })

  it('should handle SSE', () => {
    vi.mocked(useEventSource).mockReturnValue({
      data: ref('test data'),
      error: ref(null)
    })

    const { data, error } = useSSE('https://api.example.com/sse')
    expect(data.value).toBe('test data')
    expect(error.value).toBeNull()
  })

  it('should handle WebSocket', () => {
    const mockSend = vi.fn()
    const mockOpen = vi.fn()
    const mockClose = vi.fn()
    vi.mocked(useWebSocket).mockReturnValue({
      data: ref('test message'),
      send: mockSend,
      open: mockOpen,
      close: mockClose
    })

    const { data, send, open, close } = useWS('wss://api.example.com/ws')
    expect(data.value).toBe('test message')
    expect(send).toBe(mockSend)
    expect(open).toBe(mockOpen)
    expect(close).toBe(mockClose)
  })

  it('should handle dynamic routes', async () => {
    const result = await getDynamicResource('products', 1)(zod.object({ id: zod.number(), name: zod.string() }))
    expect(result).toEqual({ id: 1, name: 'Product 1' })
  })

  it('should handle query strings', async () => {
    const result = await searchResources('products', { category: 'electronics', price: 'low' })(zod.object({ id: zod.number(), name: odz.string() }).arry()))
    expect(result).toEqual([{ id: 1, name: 'Product 1' }, { id: 2, name: 'Product 2' }])
  })
})

步驟 5: 測試 api Composable 的技法

import { describe, it, expect, vi } from 'vitest'
import { useUser } from './useUser'
import * as api from './api'

vi.mock('./api')

describe('useUser', () => {
  it('should fetch user', async () => {
    vi.mocked(api.getUser).mockResolvedValue({ id: 1, name: 'John Doe', email: 'john@example.com' })

    const { user, loading, error, fetchUser } = useUser()

    await fetchUser(1)

    expect(user.value).toEqual({ id: 1, name: 'John Doe', email: 'john@example.com' })
    expect(loading.value).toBe(false)
    expect(error.value).toBeNull()
  })

  it('should handle fetch error', async () => {
    vi.mocked(api.getUser).mockRejectedValue(new Error('Network error'))

    const { user, loading, error, fetchUser } = useUser()

    await fetchUser(1)

    expect(user.value).toBeNull()
    expect(loading.value).toBe(false)
    expect(error.value).toBe('Failed to fetch user')
  })

  // Add more tests for saveUser...
})

結論

在本文中,我們深入探討了如何使用 Vitest 來全面測試 Vue 3 應用中的異步行為和 API 請求邏輯。我們涵蓋了從基本的 API 請求測試到複雜的 WebSocket 和 SSE 實時數據處理。

關鍵點包括:

  1. 使用 createFetch 和 curry functions 創建靈活的 API 請求函數。
  2. 創建和測試 composables 來封裝業務邏輯。
  3. 測試文件上傳和下載功能。
  4. 處理和測試 WebSocket 和 SSE 連接。

(個人不小心把篇幅寫太多了,目前刪減過)

在實際開發中,你可能需要根據具體的業務邏輯來調整和擴展這些測試。記住,好的測試不僅能捕捉錯誤,還能幫助你更好地理解和改進代碼。通過持續的測試和優化,你可以創建一個既高效又可靠的 Vue 3 應用。


上一篇
Day 24: 性能優化:如何利用 UnoCSS 與 Vite 減少打包大小還有優化 vue 的各式操作
下一篇
Day 26: 在 Vue 應用中實現懶加載與代碼分割以提升性能
系列文
Vue 和 TypeScript 的最佳實踐:成為前端工程師的進階利器30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言